fix(spec,runtime): ActionEngineFacade.find takes the engine query envelope, not a bare filter - #19223
Conversation
… envelope; the bare-filter shape is withdrawn
`ctx.engine.find(object, query)` now declares `EngineQueryOptions` by identity —
the same options bag `IDataEngine.find` takes — and the runtime's `find` arm
passes it through instead of building `{ where: query }` itself.
The facade's parameter had been the `where` half alone, which made the engine's
own envelope the wrong spelling at the call site: it reached the engine as
`{ where: { where: … } }`, matched no row and resolved to `[]` with no error.
Closing that at the type level the other way would have had to reserve the
field name `where` across every customer's data model.
Migration is lossless and mechanical — `find(o, f)` → `find(o, { where: f })` —
and registered as an ADR-0087 semantic entry. A bare filter is now a compile
error on both paths a caller can reach it by: an object literal fails the
excess-property check, and a `FilterCondition` variable fails TS2559.
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
`gen:migration-registry` folds in the new ADR-0087 semantic entry; `gen:skill-refs` and `gen:api-surface-declarations` follow the module graph, which moved when `ui/action-params.zod.ts` began importing `data/data-engine.zod`. The seven declaration files beyond `ui.txt` carry ORDER churn only — d.ts emit order follows the chunking, and the chunking follows that import. Measured against a pristine base worktree at the branch point: the same `build && gen:api-surface-declarations` there rewrites nothing at all, so every byte here is downstream of this diff rather than pre-existing drift. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
… new engine double Three gate-driven corrections, none of them a contract change: - `action-params.zod.ts` keeps its `:1183` / `369da918` citation, as data about where the wrap USED to live. Dropping it silently repaired a row in `check-spec-docblock-symbol-anchors`'s day-one residual, whose repair that gate's own header assigns to a `domain:spec` repair card (#16960), not to whoever edits the file next. - the new runtime double drops the `update()` it never exercised, matching its pinned sibling, so `check:engine-double-contract` has no unbound verb to pin. - `engine-double-contract.pinned.json` learns the new file (gate `--write`, additive: 810 rows, 1 added, 0 lost). `ui.txt` follows the doc comment. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b705e507e60c2b4b6eff217423eb215195bfa026 && git checkout b705e507e60c2b4b6eff217423eb215195bfa026
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb005e06aeae13bcedbea9b3827c89de3c6833a4 1e391fe8b516d0721847d9cf7daa6d720c873e8f && git checkout -B drift-repro cb005e06aeae13bcedbea9b3827c89de3c6833a4 && git merge --no-ff 1e391fe8b516d0721847d9cf7daa6d720c873e8f
node scripts/docs-audit/affected-docs.mjs --json cb005e06aeae13bcedbea9b3827c89de3c6833a4
|
受管面状态 · 已按规制请人看 · ⛔ 席位不落地
先更正我自己的一次读数我先前用本席工作检出跑的 ⇒ 以现行尺子为准:本 PR 是受管面,落地归人。⛔ 本席不翻 ready、不入队、不 arm auto-merge。
|
Contract reviewServed-tier: Reviewed against ruling D on card #15124 (comment 5644710751), item by item, on a detached worktree at the head sha (branch point ① Derived judgmentsEach change this diff makes to the accept set or the public surface:
Nits, not wrong: the facade declares ② Semver level
③ Boundary flagsTier H — the two
os-regen routing ( The 8 declaration files. Fresh Pinned sibling / Console Pin Gate. Runtime behaviour for a caller still on the old shape (North Star ④) — the blocking finding. Measured through the real facade wrapped around a real
Ruling item 4, the #14175 erratum — NOT done, correctly. That changeset was consumed at release and sits under Ruling item 3, the hotcrm half ("census at dispatch; relayed to the hotcrm seat"): no census and no relay is evidenced on the card thread or in the report, and it is not verifiable from this repo. Escalated to the dispatching seat: confirm the relay or record why none was needed. Implementer deviations, each answered: (1) What turns this into a PASS, all in this PR: (i) the facade-arm refusal with the CI on the head at review time: every required context green ( Implemented-by: VERDICT: FAIL Generated by Claude Code |
更正我自己 40 分钟前那条 —— 本 PR 不是 Tier H,复核请求已撤回@hotlong @os-zhuang 抱歉打扰。 三次读数,一次比一次贴近事实
⇒ 读数 2 的 读数 3 的原始输出在 ⭐ 亮对照,⛔ 否则「状态空」可能只是生成器没写:我往 ⇒ 生成器确实在写这两条路径,且已提交的字节就是它的输出。 本 PR 现在的真实状态达档契约复核判 FAIL(记录 5746635882),理由是一条真实缺陷:旧 filter 形状在无类型通道上带 null 值键时既不报错也不过滤,静默返回全部行。补丁轮已发给施工席, ⇒ 本 PR 会先改再说,head 会移动。⛔ 两位在此之前不必看。 Generated by Claude Code |
… arm Contract-review FAIL, finding ①: passing the envelope through opened a SILENT path on the untyped channel. `ObjectQL.find`'s unknown-option refusal (#4371) exempts a null VALUE — correct for an option bag, where a null is a withdrawal; wrong for a filter, where `{ deleted_at: null }` is the "rows with no X" idiom. Measured on a real engine over three seeded rows: the key was dropped unexecuted and the read returned ALL THREE, the excluded row included, with no error. Before this card the wrap kept every filter key away from that exemption. The arm now judges its own parameter first and refuses any key the envelope does not carry — null-valued included — naming the stray key and prescribing `where`. The key set is read off `EngineQueryOptionsSchema`, the same declaration the parameter's type names, so the compile-time and runtime refusals are one fact. Also: `action-body-identity.test.ts` was still calling the facade with the withdrawn shape at two sites and riding green (the facade returns `any` and the double did not validate); its "the caller's predicate must survive" case asserted `toBeDefined()` on the recorded entry, which is true whatever the arm did with the filter. Both migrated, and that case now asserts the predicate. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…review fixes `gen:migration-registry` picks up the corrected acceptance criteria (a type check alone proves nothing for a handler the type never reached); `gen:api-surface-declarations` picks up the member doc, which now states the runtime half and why a null-valued key is the load-bearing case. `check:generated`: all 16 artifacts up to date. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…on sqlite Two gate-driven corrections to the refusal, both mechanical: - `check:doc-authoring` — a runtime message reaches authors and operators who have no tracker to resolve `#NNNN` with, so the card id moves to the `//` comment above the constant. The changeset's quoted message follows, and now quotes the envelope key list the refusal actually prints (measured: 12 keys). - `check:driver-memory-census` — the real-engine pin bound `@objectstack/driver-memory`, whose consumer set #5704 froze and #6664 ruled to a ledger; a new binding there is a maintainer ruling, not bookkeeping. It binds sqlite `:memory:` instead, which is what #5704 migrated the test backends to. Nothing about the pin needed that driver: what has to be real here is the ENGINE, because the null exemption is the engine's. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Second review, new head. Held against ruling D on card #15124 (comment 5644710751) and against the prior FAIL record 5746635882 (head ① Derived judgmentsEach change to the accept set or the public surface, judged:
Nits carried, not wrong: the changeset's "what this opens" list omits ② Semver level
③ Boundary flagsThe four items of record 5746635882, item by item.
Tier H — the two Six keys the engine honours and the arm refuses — adjudicated as a contract question. The ruling names "the engine's published query type by identity"; that is Two gate-forced deviations. (a) sqlite Ablation — re-done by me, above; the guard neutralised reds 3 PR pins + 2 of mine with the null-valued row resolving; tree restored byte-clean. Pinned sibling / os-regen routing.
Implementer deviations (report 5747228829), each answered: (1) driver swap — above; gate green, pin not weakened. (2) tracker id moved — above; gate green. (3) two more texts corrected — verified, both true. (4) file surface +1 ( Still open from the FAIL record, none of it this PR's: ruling item 3's hotcrm half ("census at dispatch; relayed to the hotcrm seat") is still evidenced nowhere on the card thread and is unverifiable from this repo — re-escalated to the dispatching seat. Ruling item 4's #14175 Taken on the PR's word: nothing load-bearing. Not re-run by me: the repo-wide eslint sweep, the 121-family Implemented-by: VERDICT: PASS Generated by Claude Code |
…cade-query-envelope
…ed tree Baseline drift, not a code change. `origin/main`'s #19219 moved `ComponentPropsMap` and `ObjectTimelinePropsSchema` after this branch forked, and `api-surface-declarations/**` is a `merge=os-regen` path — so the merge produced a `ui.txt` that was current for neither side, which is what turned `Check @objectstack/spec declaration text` red. Regenerated from a real build of the merged tree (34/34 declaration files emitted; ⛔ no `OS_SKIP_DTS`), via `scripts/pm/os-regen-merge.sh`. BOTH SIDES asserted present afterwards, by quoted-exact name against the INDEX blob rather than the worktree, with a dark control reading 0 files: this branch's `find(object: string, query: EngineQueryOptions)` and `ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION`; #19219's `ObjectTimelineProps`, `ObjectTimelinePropsSchema` and `ComponentPropsMap`. Note the two `navigation` keys that branch also added live in `authorable-surface/ui.json` and in NO declaration file — a grep scoped to `api-surface-declarations/` reads 0 for them out of range, not out of loss, so the assertion ran over the whole tree and printed paths. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Third review, new head. Held against ruling D on card #15124 (comment 5644710751) and against both prior records — 5746635882 (head ① Derived judgmentsWhat moved since the PASSed head. Smuggling. The PR's three-dot contribution NOW (merge-base with Both sides survive, read at file grain, paths not counts. Main's 31 paths in the merged range The regenerated artefacts, re-derived with firing controls. The red that caused this round. os-regen routing. The contract itself on this head — unchanged by identity from ② Semver level
Nothing in the sync moved the level, the migration, the marker or the tombstone-equivalent; ② stands as adjudicated in 5747325899. ③ Boundary flagsTier H — the two The four items of record 5746635882, item by item, on THIS head — a sync can undo a fix as easily as a hand edit.
What still stands from each prior record. From 5746635882: the ①-level judgments of the signature, the arm, the pins, the ADR-0087 entry and the docs; the Tier H lift; the sibling reading ( Is this head WORSE than the head that passed, in any respect? No. Every hand-written and generated PR file is byte-identical except Landing state, for the owning seat (not a contract defect). Taken on the PR's word: nothing load-bearing. Not re-run by me on this head: the repo-wide eslint sweep, the 121-family Implemented-by: VERDICT: PASS Generated by Claude Code |
达档复核第三轮 PASS —— 但本席主动压住不同步、不入队,理由是接力次序,⛔ 不是这张 PR 有问题
Record of record: 本 PR 评论 5747584567, 三份记录的链条:5746635882(head 本轮复核自己跑到的两件,值得记账
⛔ 为什么现在不动它本席在 2026-09-20T04:23Z 重测了漂移: 无驱动探针 ⇒ 这张 PR 也需要一次同步。但 PR #19235 处在完全相同的状态(复核已 PASS、同样被 ⇒ 本席按 「多个已实现 PR 全碰生成物 ⇒ 串行接力一次只放行一个」 做次序选择:先同步并放行 #19235(7 个文件,面小),它落地后再同步本 PR。这不是对本 PR 的降级 —— 它是 BREAKING narrowing、复核三轮全过,只是接力里排第二。
⭐ 这一整个循环已立为决策卡 #19244( 一件复核两轮都答不了、归本席升级的ruling item 3 的 hotcrm 普查/转达在本卡线程上没有任何证据,且从本仓不可验证。两轮复核各自点名一次。⇒ 本席据实升级:它既不在本 PR 的范围内,也无法由本仓的任何读数关闭。⛔ 不以「没找到」当作「不存在」。 Generated by Claude Code |
…cade-query-envelope
…ged tree Baseline drift, not a code change — the fourth sync lap on this branch. Main's #19226 and #19235 moved `packages/spec/api-surface-declarations/{data,root, system,ui}.txt`, and that directory is a `merge=os-regen` path, so the merge produced four files current for neither side. Regenerated from a real build of the merged tree (34/34 declaration files emitted; ⛔ no `OS_SKIP_DTS`), via `scripts/pm/os-regen-merge.sh`, with `MERGE_HEAD` confirmed absent first — the build opens with `gen:schema`, and running that in MERGE state is the anchor-rollback trap.⚠️ The `MM` grade was live here and was read on purpose. After regenerating, the index held main's side (803/535) while the worktree held the regeneration (323/4); a bare `git commit` would have landed the index. `git add -A` first, then `git diff --cached` re-read as the 323/4 it should be, and every one of the four index blobs hash-matches its worktree file. BOTH SIDES asserted by quoted-exact name over the WHOLE TREE with paths printed, then again against the index blobs, with a dark control reading 0 files: this branch's facade signature and prescription constant; #19226's `DEFAULT_VIEW_ROW_LIMIT`, `KanbanConfigParsed` and the three `ui/{Gallery,Kanban,Timeline}Config:limit` keys; #19235's `RecordRelatedListProps.columns[number]` and `z.array(ListColumnSchema)`; and #19219's `ObjectTimelinePropsSchema` carried forward. Note the three `limit` keys live ONLY in `authorable-surface/ui.json` and `authorable-defaults/ui.json` and the related-list row ONLY in `content/docs/references/ui/component.mdx` — a grep scoped to the declaration files reads 0 for them out of range, not loss. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Fourth review, new head. Held against ruling D on card #15124 (comment 5644710751) and against all three prior records — 5746635882 (head ① Derived judgmentsWhat moved since the PASSed head. Evil-merge check. A driverless bare clone's Smuggling. The PR's three-dot contribution NOW (merge-base Both sides survive, at file grain, paths not counts. Main's 35 paths in the merged range The regenerated artefacts, re-derived with firing controls, and the The red that caused this round. os-regen routing. The contract itself on this head — unchanged by identity from the two PASSed heads, and re-exercised on the MERGED tree rather than assumed. Spec pins ( ② Semver level
Nothing in the sync moved the level, the migration, the marker or the tombstone-equivalent; ② stands as adjudicated in 5747325899 and re-affirmed in 5747584567. ③ Boundary flagsTier H — the two The four items of record 5746635882, item by item, on THIS head — a sync can undo a fix as easily as a hand edit.
What still stands from each prior record. From 5746635882: the ①-level judgments of the signature, the arm, the pins, the ADR-0087 entry and the docs; the Tier H lift; the routing of Is this head WORSE than the head that passed, in any respect? No. Every hand-written and generated PR file is byte-identical except the four os-regen declarations, now current for the MERGED tree where before they were current only for lap 3's base, and Pinned sibling / Merge-sensitive repo gates, all run by me on the head tree, all exit 0: Landing state, for the owning seat (not a contract defect). Taken on the PR's word: nothing load-bearing. Not re-run by me on this head: the repo-wide eslint sweep, the 121-family Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance —— 第四轮达档复核已采纳,三条落地前检通过
Record of record: 本 PR 评论 5748188067, 四份记录的链条(全部留档,⛔ 无一被改写):5746635882 head 档位证据(读自子代理自己的转录,常量现场 import): 三条落地前检:
受管面:复核在装了依赖的树上跑生成器并以 ⭐ 本轮最硬的一条读数:那个静默丢弃被带对照地演示了复核自己搭真引擎跑六行矩阵,并加了一条亮对照: ⇒ 「这个臂防住了什么」不再是论证,是并排的两个读数。 MM 陷阱的结果,从已提交的树上验复核重跑 ⛔ 一处本席的框架被更正本席先前说这一圈的漂移源是「两张已落地 PR」。复核实测:合并区间里是四个 main 提交 —— #19234、#19226、#19235、#19251。⇒ 计数本身不变(漂移的仍是那 4 个声明文件),但「两张」是本席数错了。已照此更正。 入队前的 os-regen 漂移,在 arm 这一刻重测⇒ 亮对照在响(主干确实动了),而那 1 个文件不在本 PR 的生成物面上 ⇒ 那个 0 是读数。 串行接力:本席此刻队列里没有其它 PR(#19226、#19235 已落地)。 一件四轮复核都答不了、本席据实升级的ruling item 3 的 hotcrm 普查/转达在本卡线程上没有任何证据,且从本仓不可验证。第二、三、四轮各自点名一次。⇒ 它既不在本 PR 范围内,也无法由本仓任何读数关闭。⛔ 不以「没找到」当作「不存在」。 落地路径:ready → auto-merge → 合并队列。⛔ 队列外不合并。 Generated by Claude Code |
…hipped prescriptions were denying a door that exists (objectstack-ai#19234) Fixes objectstack-ai#17487 Clause-②: no ## The defect, and its direction Three shipped, customer-facing prescriptions in `@objectstack/spec` stated in the present tense that the runtime confirmation door had not shipped. It has: `actionConfirmationRefusal` is called pre-dispatch by `invokeBusinessAction` in `@objectstack/runtime`, and the MCP `run_action` tool grew the `confirm` member in the same change (the card behind it, objectstack-ai#15942, is done — `state_reason=completed`; its changeset `action-confirmation-gate-enforced` is still pending, so the door is on `main` and not yet released). So the published text denied a door that exists, and it failed in the dangerous direction: an author who reads it concludes the safety flag stops nothing, and either arranges a human in the loop some other way or stops setting the flag — losing the gate at the moment it starts working. That is the ADR-0049 false-compliance class with the sign flipped. ## Re-derivation — all three sites read on today's `origin/main` Triage's unblock comment verified site 1 only and said the other two were unmeasured. All three were re-read at merge base `805811e0d`. | # | Path | Current text | Verdict | |---|---|---|---| | 1 | `packages/spec/src/ai/tool.zod.ts` — `TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation` | "the declaration is the contract, not yet the behaviour — the runtime door that performs the refusal ships separately, and until it does, setting the flag does NOT stop an unconfirmed call. Do not try to verify the gate by invoking the operation without the member: until that door lands, such a call simply RUNS." | **FALSE today** | | 2 | `packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts` — `replacement` | "The refusal is DECLARED, not yet performed — the runtime door lands in objectstack-ai#15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange" | **FALSE today** | | 3 | the same file — `acceptanceCriteria` | "Do NOT try to 'prove the gate' by invoking the operation without the confirmation member: the runtime door that refuses lands in objectstack-ai#15942, so before that ships the call is not refused, it RUNS the destructive operation." | **FALSE today** | **Correction to the card's count of the carriers.** The card names the `spec-changes` entry, the upgrade guide and the `os migrate meta` projection as if they were separate sites. They are not: all three are projections of the **one** ADR-0087 D3 entry file above. The measurement is therefore **three false prescriptions living in two source files**, plus three generated artefacts that carry them (`src/migrations/registry.ts`, `spec-changes.json`, `docs/protocol-upgrade-guide.md`), all regenerated here by `check:generated --fix`. Sweep radius for "is that all of them": eleven denial phrasings grepped repo-wide (`not yet the behaviour`, `ships separately`, `not yet performed`, `stops nothing`, `simply RUNS`, `until it does`, `until that door`, `door lands`, `yours to arrange`, `nothing server-side`, `no pause`), with `requiresConfirmation` lighting 10 files under `packages/spec/src` as the positive control. Two adjacent texts were read and left alone as **NOT A DEFECT**: `packages/spec/src/contracts/ai-service.ts` already states the gate in normative present tense, and `content/docs/ai/tools.mdx` says the retired **tool**-level key "returns only together with its enforcement", which is still true — the tool key has not returned. Two further readings are recorded under *Acceptance notes*. ## What the prose says now, and what holds it there Each prescription now states the refusal in the present tense **with the door's bounds**, because an unbounded "the platform refuses unconfirmed calls" is this same defect in the other direction. Read off the door's own docblock and its shipped changeset, never inferred: - the refusal is `ACTION_CONFIRMATION_REQUIRED`, 428, naming the action and the member `confirm: true`; - a GATE, not a queue — nothing is parked, and a refused call did not run: the gate sits before `loadActionSubjectRecord`, so no record is read and none written; - the enforced set is the doors that enforce the author's `ai.exposed` opt-in — today the action door reached from MCP `run_action`. REST `/actions` is **not** `ai.exposed`-gated and sits outside the gate, so an API-key agent on that route still needs its own human; - only the author's declared `ai.requiresConfirmation: true` refuses, and only the boolean `true` confirms; the wider `list_actions` heuristic advises and never refuses; - `confirm: true` is an unverifiable caller claim: the gate makes forgetting loud, it does not prove a human. `packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was missing the first time — the prose was never bound to the function it describes, which is how it rotted. It reads the three shipped strings **and** the runtime door, and fails in both directions. **No pin was moved.** `ui/action-requires-confirmation-docblock.pin.test.ts` was read: it anchors on the `ai.requiresConfirmation` JSDoc in `ui/action.zod.ts` and on `actionLooksDestructive`, neither of which this diff touches, so it covers none of the three sites and stays as it is. ## Clause-②: no — the accept set did not move `check:authorable-surface` and `check:api-surface` are green with **zero** diff under `packages/spec/authorable-surface/` and `packages/spec/api-surface/`. The pin's last case feeds the same authored metadata in before and after: `tool.requiresConfirmation` still refused, a minimal tool still accepted, `action.ai.requiresConfirmation` still accepted for both `true` and `false`. What moved is string content inside `dist` and `spec-changes.json`, which is why a `patch` changeset is owed and present. ## Tests, and the reverse verification `pnpm --filter @objectstack/spec test` — 499 files / 14614 tests passed. `test:repo` — 34 files / 580 tests passed. `typecheck` — clean. New pin: 8/8. Three ablation legs, each mutated on disk through `scripts/ablation-replace.mjs` (anchor hit declared, blob hash proven to move), direction predicted before the run, restored and proven by blob hash against `HEAD` with `git diff HEAD` empty: | leg | mutation | predicted | observed | |---|---|---|---| | 1 | re-insert `The refusal is DECLARED, not yet performed` into the D3 entry's `replacement` | RED on "no shipped prescription denies the refusal" | RED, naming the replacement carrier | | 2 | rename the gate call inside `invokeBusinessAction` | RED on "the AI-facing door still calls the gate pre-dispatch" | RED | | 3 | make the REST `/actions` door name the gate | RED on the over-claim guard | RED | Leg 3's **first attempt was a no-op** and is reported as such: the replacement text still contained the anchor, so `ablation-replace` refused (anchor drop 0, not the declared 1) and nothing ran. It was re-anchored and re-run; the reading above is the re-run. ## Gates All 85 commands derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` for this diff were run locally and exit 0, exit codes captured before any pipe. Eight first returned a stale-`dist` or `PREREQUISITE NOT MET` result (exit 1 / exit 3 — not measured, not findings); they were re-run green after `pnpm --filter @objectstack/spec build` and a full `turbo run build` closure. `pnpm lint` (`eslint . --no-inline-config`, whole repo, no narrowing) exits 0 at `HEAD`. CI still owns its own farm: the five path-scheduled CI jobs, the 11 wide-population families and the artifact rosters are outside that 85 and are NOT MEASURED here. ## Acceptance notes Two readings taken while re-deriving, both **out of scope for this card** and neither edited here: 1. `packages/spec/docs/MCP_GUIDE.md` (around the "Side Effects" section) tells an author to gate side effects with "`ai.requiresConfirmation` on the underlying **action** (+ the HITL approval queue)" and then warns, in the adjacent block, that "nothing server-side pauses on it". The warning is correctly scoped to the MCP capability descriptor in that page's examples and is true of it; but the approval-queue requirement now overstates what the action-level flag needs, and the two paragraphs read together in the card's own dangerous direction. Not in the declared file surface. Reported for filing with dedupe words: `MCP_GUIDE`, `requiresConfirmation`, `HITL approval queue`, `nothing server-side pauses`, `confirmation gate`. 2. `content/docs/ai/actions-as-tools.mdx` — the "Human-in-the-loop approval" section still says that on the open MCP path "the approval step lives at the protocol boundary" (client-side prompting), and the numbered open-MCP action-gate list enumerates five gates without the confirmation gate that now sits between the param contract and the subject-record load. An omission against a contract that `@objectstack/spec/contracts` declares. Reported for filing with dedupe words: `actions-as-tools`, `human-in-the-loop`, `protocol boundary`, `run_action`, `confirmation gate`. Noted, not filed: `packages/spec/src/api/error-code-ledger.zod.ts` says of the `ACTION_CONFIRMATION_REQUIRED` row that "the door will assert this exact string by value" — a forward tense about something that is now true. It misleads nobody about the gate and it is provenance prose about the row's split registration, not a prescription. Successor: the next change that touches that ledger row. ## Occupancy Re-scanned at 2026-09-20T01:52Z over all 21 open PRs, with PR objectstack-ai#17076 (639 files) fully paged so no path is under-read. `packages/spec/src/ai/tool.zod.ts`, the D3 entry, `spec-changes.json`, `docs/protocol-upgrade-guide.md`, `vitest.repo-tests.json` and `src/ai/tool.test.ts` all read FREE. Firing controls in the same scan: `packages/spec/src/ui/component.zod.ts` HELD by objectstack-ai#19219, `packages/spec/src/ui/view.test.ts` HELD by objectstack-ai#19226; dark control (a nonexistent path) reads FREE. One reading to flag: `packages/spec/src/migrations/registry.ts` reads HELD by objectstack-ai#19223, objectstack-ai#19090 and objectstack-ai#18319 — it is a generated, `merge=os-regen` artefact and none of those three touches the D3 entry this diff edits, so the contention is the one the regen driver exists for rather than two hands on the same prose. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ade.find`'s query envelope (objectstack-ai#19315) Fixes objectstack-ai#19237 Clause-②: no⚠️ Notation: TypeScript angle brackets are written with PARENTHESES throughout this body — `Omit(EngineQueryOptions, 'context')` means the `Omit` utility type. The platform rewrites tag-shaped fragments in a body, and a fence does not protect them, so the real spelling lives in the diff. The action facade's `find` accepted a caller-written `context` that type-checked and the runtime did not honour — ADR-0049's declared-but-unenforced shape on the one key that carries identity and tenant. This takes the **remove** arm, at the declaration layer only: the parameter becomes `Omit(EngineQueryOptions, 'context')`. **No runtime behaviour changes.** ## The premise, measured FIRST — it HOLDS The dispatch made the ruling conditional on a census: *no call site writes a `context` on a facade query and relies on it to narrow identity or tenant*. Measured before a line of fix was written. **Instrument** (`census3.mjs`, three arms, run against the tree at `1739f71879f`): | Arm | What it matches | Why it exists | |:---|:---|:---| | A | `RECV.engine.VERB(` where `RECV` is an action-ctx name | the canonical handler spelling | | B | bare `engine.VERB(` in a file that destructures `engine` out of a ctx | `examples/app-todo` writes it this way | | C | `V.VERB(` where `V` is assigned from `buildActionEngineFacade(...)` | closes arm A/B's blind spot: a facade held in a local variable | Each call's second argument is extracted by **balanced-paren scan**, not a line regex, so a multi-line envelope is read whole. **Radius**: 8292 tracked text files — the whole repository, not the importers of `ActionEngineFacade`. That denominator is deliberate, and it is the one PR objectstack-ai#19223 warned about: the facade is reached through `ActionHandlerContext.engine`, so an importer count of the facade type is the wrong population. **Readings**, exit codes captured before any pipe: - facade call sites **123** (armA 86, armB 10, armC 27); of these **47 are `find`** - sites writing a `context` key: **11** - `find` sites writing a `context` key: **1** That one is `packages/runtime/src/action-engine-facade-find-envelope.test.ts:126` — **the pin that asserts the key is NOT honoured**, added by objectstack-ai#19223. It is the instrument's **firing control**: arm C demonstrably sees a real facade `find` carrying a `context`. The other 10 are not facade sites, and each was classified by reading the file rather than by name: - 7 in `packages/objectql/src/internal-fields.test.ts` — `ctx` there is `Awaited(ReturnType(typeof buildEngine))`, a **real ObjectQL engine**; sibling calls to `findOne` and `aggregate` are members `ActionEngineFacade` does not declare. - 3 in `action-engine-facade-find-envelope.test.ts:209-211` — a different `engine`, built by the file's own `makeRealEngine()`; they pass a third argument, and the facade's `insert` takes two. **Dark control**: the same instrument with a member and a builder that cannot exist (`.engineZZZQ`, `buildActionEngineFacadeZZZQ`) — exit 1, `FACADE_SITES total=0` on all three arms. **Sibling radius**: `objectui` at `dda8f3815df` — `git grep` for `ActionEngineFacade`, `ActionHandlerContext` and `ctx.engine.` exits **1 / 0 hits**, with a firing control in the same tree (a token that certainly exists) exiting 0. ⇒ **Zero live call sites.** The p0 upgrade trigger does not fire. `priority:p1` stands. ## Mechanism: OVERRIDE, not drop — traced to a named line At `origin/main` = `1739f71879f`, read 2026-09-20T08:42Z: `packages/runtime/src/action-execution.ts:1620` const rows = await ql.find(object, { ...(query ?? {}), context } as any); `context` is spread **last**, after the caller's envelope, so the facade's own elevated `ExecutionContext` (minted at `:1560` by `buildActionExecutionContext(ec)`) replaces whatever the caller put under that key. The key reaches the engine; the caller's **value** does not. PR objectstack-ai#19223's body claim holds on today's tree, and it is override rather than drop. ## The third card fact: the sibling arms do NOT share the shape `ActionEngineFacade` declares exactly four members, and only one takes an options bag: insert(object, data) update(object, id, data) delete(object, idOrIds) find(object, query) ← the only bag There is no `findOne` and no `count` on this facade. The write doors have nowhere to carry a `context` at the type level, so there is nothing to price and nothing to widen this diff onto. Reported as measured, per the order. ## What changed - **`packages/spec/src/ui/action-params.zod.ts`** — the declaration. `find(object, query: Omit(EngineQueryOptions, 'context'))`, plus the member doc rewritten: why the key is gone, and the asymmetry it leaves. - **`packages/spec/src/ui/action-params.test.ts`** — objectstack-ai#15124's identity pin retargeted to the narrowed shape; a second pin that reds **only** when `context` becomes writable again; a value-level refusal pin with a positive control. - **`packages/runtime/src/action-execution.ts`** — **comment only, zero behaviour.** The arm's docblock now states that the type no longer admits the key while this arm still does, and why closing that half is not a type narrowing's business. - **`content/docs/ui/actions.mdx`** — the callout gains the one subtraction. - Generated: **none**. This PR originally regenerated `api-surface-declarations/ui.txt`; main deleted that whole artefact family (17 shards) in `2277d1fcd10`, so the regeneration was dropped in the merge. The artefact that replaced it, `api-surface-signatures.json`, does **not** move for this narrowing — `gen:api-surface` rewrites it byte-identically (blob `b2099d11828`), because it hashes `checker.typeToString()` of the 27 `defineX` factories, which prints a type reference without expanding it. ## The pin is TYPE-level, and that is deliberate `FindQueryCarriesNoContextKey` asserts the key is absent from the declared slot; the two `@ts-expect-error` directives red if a literal carrying `context` starts compiling. A **runtime** pin would assert a refusal that does not exist and must not: adding one makes the facade throw on an identity key, which is a runtime permission change no ruling covers. The runtime's own pin is untouched and still green. The file is inside the checked zone — `check:test-typecheck` reports `packages/spec/tsconfig.test.json` compiling 54 files — so these are not phantom directives. ## Reverse verification Fix committed first, then the declaration alone reverted to `EngineQueryOptions`: - **on-disk proof** — narrowed spelling 1 → 0, widened 0 → 1, blob `3f73de3ae0f` → `58f5dc6b90e`; a no-op edit would have been caught here and the reading voided. - **ablated** `pnpm --filter @objectstack/spec typecheck` → **exit 1**, `src/ui/action-params.test.ts: 4 type error(s)` — the two asserts plus the two now-unused `@ts-expect-error` directives. - **restored** with `git checkout HEAD -- PATH` (never a bare checkout, which reads the polluted index): `git diff HEAD` empty and `git hash-object` back to `3f73de3ae0f`, byte-identical. A trap on EXIT/INT/TERM carried the restore, with an absolute repo root. Direction predicted before the run and observed: **red**. ## Verification — per consumer package, on the merged head `d55c3d9e772` | Package | Reading | |:---|:---| | `@objectstack/spec` | 500 files / **14644** tests passed · `typecheck` exit 0 | | `@objectstack/runtime` | 268 files / **3705** passed, 1 skipped · `typecheck` exit 0 | | `@objectstack/objectql` | 300 files / **5009** passed · `typecheck` exit 0 | | `@objectstack/example-todo` | 7 files / **238** passed · `typecheck` exit 0 | `@objectstack/objectql` is **not** on the dispatched floor list: the census found it, at `packages/objectql/src/engine-write-not-found-gate.test.ts`, which builds a real facade through `buildActionEngineFacade`. Run because it is a consumer, and said so.⚠️ One reading was thrown away rather than reported: the first `runtime` run answered *242 test files failed / 7 tests failed*, which was `Cannot find package` on an unbuilt dependency closure — PREREQUISITE NOT MET, not a red. Re-run after `pnpm --filter '@objectstack/example-todo^...' build` and reported above. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived from this tree, re-derived after the merge (same 107, no families added or dropped): **107 of 107 green**, each exit code redirected to its own file and read back before any pipe, then reconciled with `--ran` carrying the codes — `107 run, 0 NOT-MEASURED (a DERIVED zero)`. Two needed a second run, and both were prerequisite misses rather than reds: `check:skill-examples` (exit 1, `packages/client-react/dist` unbuilt) and `check:dual-build-cjs-loads` (exit 3, its own `PREREQUISITE NOT MET — ⛔ This is NOT a pass`). Both green after building the missing packages. `check:pm-widening-tells` is **green** — the T1 tell that card objectstack-ai#19099 records against this shape did not fire, so the `Clause-②: no` declaration needed no over-declaring to get past a gate. **Lint, repo-wide rather than narrowed:** `eslint . --no-inline-config` over all **6916** files eslint's own config judges — **0 errors, 0 warnings**, exit 0, at `d55c3d9e772`. The file count is read from eslint's own `--format json` output, not estimated. No type-aware linting is configured (`eslint.config.mjs` states it carries no `parserOptions.project` and no typed rules), so nothing in this diff can move an untouched file's verdict. ## Declaration `Clause-②: no` — this puts no new key on a published payload; it removes one from a parameter type. The lane charter's line that a narrowing does not trigger clause ② is the criterion, and `check:pm-widening-tells` agrees with it mechanically. The **changeset** separately carries `Clause-②: no (narrowing)`, which is signal (4) to `check-adr-0087-registration`: an accept-set narrowing on a published type is exactly what objectstack-ai#16421 built that signal for, so it is declared rather than left to prose, with an `already-registered` disposition naming `action-engine-facade-find-query-envelope` — the entry objectstack-ai#19223 landed, which already tells an upgrader that a caller-supplied `context` is ignored. That gate is green. ## Acceptance notes **Noted, not filed — the asymmetry this leaves, stated so nobody reads it as an oversight.** After this diff the facade's `find` arm refuses (at runtime) every top-level key the envelope does not carry, accepts-and-honours the ones it does, and accepts-and-**overrides** exactly one: `context`, for untyped callers only. Closing that last cell means a runtime refusal on an identity key — the maintainer's floor, not a dev's and not a seat's, and the dispatch prohibited taking it here. It is recorded on both halves of the contract (the spec member doc and the runtime arm's docblock, the latter with an explicit "do not finish the job here without a ruling"). **Carrier: whoever holds the next ruling on this surface** — there is no PR or person this file is waiting on today, so it is written down where the next editor of either half will read it, rather than filed as a card nobody is dispatched to.⚠️ **Not a finding, but worth one line for the next census on this surface:** a receiver-name heuristic over `.engine.` is not sound here — 10 of the 11 `context` writers it flags are the data engine, which honours the key. Only a type or construction anchor (`buildActionEngineFacade`, or the `findOne`/`aggregate` members the facade lacks) separates the two populations. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15124
Clause-②: yes
ctx.engine.find(object, query)now takes the engine's query envelope —EngineQueryOptions, by identity, the same options bagIDataEngine.findand ObjectQL's ownengine.findtake. The bare-filter parameter shape is withdrawn. One platform, one query shape.Director seat ruling, decision batch #123 item 3, letter D (comment 5644710751), carrying the maintainer's 「同意」:
BREAKING for action handlers, landed under the launch-window convention: no deprecation window, migration in the changeset and registered as an ADR-0087 semantic entry (
action-engine-facade-find-query-envelope). The changeset carries the arm the level axis needs —Clause-②: yes (narrowing)— and aminorbump on both published packages, per the no-major rule.Migration
ctx.engine.find('task', { status: 'open' })ctx.engine.find('task', { where: { status: 'open' } })ctx.engine.find('task', {})Lossless and mechanical.
tsc --noEmitover a consumer's handlers finds every unmigrated call, because a bare filter is now a compile error (below).What changed
packages/spec/src/ui/action-params.zod.ts— the declaration.find(object, query: EngineQueryOptions), plus a member doc that states the envelope, the migration, the measured refusal and thecontextrule.packages/runtime/src/action-execution.ts— thefindarm passes the envelope through. The double-wrap is gone;contextis still spread last, so the facade's own elevatedExecutionContextwins over a caller-supplied one.packages/spec/src/ui/action-params.test.ts—ActionEngineFacade.find(object, query)takes a bare filter whileinsert/update/deletetake explicit shapes — the type says neither, and reading it wrong returns empty with no error #14175's MEASURED-GAP pin flipped into a refusal pin, plus positive controls for the envelope keys a handler can now reach.packages/runtime/src/action-engine-facade-find-envelope.test.ts(new) — the runtime half: what argument the engine actually RECEIVED, not what rows came back. A rows-only pin is exactly what the original defect passed.packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts(new) — the ADR-0087 D3 entry. Semantic rather than a D2 conversion because the rewrite lives in an authored TypeScript function body, whichmigrate metacannot reach.examples/app-todo/src/actions/task.handlers.ts— the one in-repo caller (see the probe below).content/docs/ui/actions.mdx— the callout, inverted, with an upgrade note.api-surface-declarations/, and the two skill reference indexes.Measurements the dispatch asked for
The blast-radius probe, re-run with controls.
ActionEngineFacadehas zero importers outsidepackages/specat24d622b9— the card's reading holds. Probe exit 0 / 40 hits, all prose or the unrelated runtime symbolbuildActionEngineFacade; firing controlActionHandlerContextfinds a real cross-package import (examples/app-todo/.../task.handlers.ts), dark controlActionEngineFacadeZZZexits 1 / 0 hits. Exit codes captured before any pipe.ActionHandlerContext.engine, so every handler annotated with the published context type is a typed caller without ever namingActionEngineFacade. That is where the one real call site is.Does anything in-repo call the facade with a bare filter? Yes — one:
deleteCompletedTasksinexamples/app-todo/src/actions/task.handlers.ts, migrated here. Its siblingexportTasksToCSVpasses{}and is unchanged. No other in-repo caller exists.Is the envelope type importable without a cycle? Yes, no type move needed.
packages/spec/src/data/data-engine.zod.tsdoes not import fromui/(probe exit 1), and the import isimport type, so it is erased entirely.Loud refusal or silent acceptance? Loud, on both paths — and the second one is the half I expected to be open:
{ status: 'completed' }) fails the excess-property check;FilterConditionvariable fails TS2559 —EngineQueryOptionsis a weak type, every key optional, and a bag of field names has no property in common with it.FilterCondition's string index signature does not rescue it.Both are pinned. Only a compile error is reachable; no runtime-only refusal is involved.
Does it widen? Yes, and deliberately:
fields,orderBy,limit,offset,expandandsearchare reachable from a handler for the first time — the old parameter had nowhere to carry them. The one key worth calling out iscontext: the envelope admits it because every engine option bag does, but this facade is trusted and context-less by design, so a caller-suppliedcontextis overridden, not honoured. Documented on the member and pinned in the runtime test, because it is a security-shaped property of a spread ORDER.The 8 regenerated declaration files, measured rather than waved at. Only
ui.txtcarries semantics; the other seven carry declaration-emit ORDER churn (enum member order,Excludekey order), because adding oneimport typemoved the d.ts chunking. Control: a pristine worktree at the branch point ran the samebuild && gen:api-surface-declarationsand rewrote nothing at all. So every byte here is downstream of this diff, not pre-existing drift.skills/**readingsBoth changed files are generator-owned (
gen:skill-refs), andcheck-skills-token-ratchetclassifies them as "measured, not ratcheted" — no authored budget is spent (129392 / 145656, -16264, unchanged by this PR).skills/objectstack-data/references/_index.md67 → 70 (+3);skills/objectstack-ui/references/_index.md57 → 60 (+3). Purely additive: the three new transitive spec modules the import pulls in.SKILL.md: 6145 → 6145 (0). NoSKILL.mdis touched by this diff.skills/**). Landing waits for its tier's record; I have left it draft.Verification
Repo-wide, not narrowed:
eslint . --no-inline-configover all 6911 files eslint's own config judges — 0 errors, 0 warnings. No type-aware linting is configured, so nothing here can move an untouched file's verdict either way.dispatch-gates --commandsaction-params,migrations,data-engine)examples/app-todotests@objectstack/spec typecheck@objectstack/runtime typecheck@objectstack/example-todo typecheckcheck:generatedEverything ran against a real build — no
OS_SKIP_DTS. All heavy runs went throughscripts/pm/os-verify-lock.sh.Acceptance notes
To file (contract-violation class).
scripts/check-spec-docblock-symbol-anchors.mjsdeclaresCENSUS_RESIDUALshrink-only and its stale-row check prescribes deleting a row the day its citation is repaired — but two pinned counters make that deletion impossible. Measured on this exact repair: deleting the one repaired row reds the self-test onliveTriage.pinned.length === CENSUS_17065.hardFindings(a frozen, dated census), and then again onSELF_TEST_BATTERY_FLOOR64 → 61, because the roster loop registers three cases per row. The census cannot simply be decremented either — its own arithmetic check bindstrackedTargetLineCitations + declinedCitations === commentProseLineCitations. So the first legitimate repair has only dishonest exits: leave a stale row, or edit a dated record until it no longer reproduces at its own sha. Dedupe words: check-spec-docblock-symbol-anchors, CENSUS_RESIDUAL, hardFindings, SELF_TEST_BATTERY_FLOOR, shrink-only residual. Carrier: #16960, the repair card that gate's own header names, which hits this on its first repair.⭐ Which is why this PR deliberately KEEPS the
:1183citation in the member doc, as data about where the wrap used to live. My first rewrite dropped it, which silently repaired that residual row — a repair this lane was not dispatched to make and cannot complete honestly.Noted, not filed. A branch
claude/issue-19011-revert-declaration-text-snapshotis in flight against the declaration-text snapshot family, which is the same artifact family as the eight files regenerated here. Carrier: whoever lands #19011 — a textual collision is likely, and the resolution is a regeneration, never a textual merge.Deliberately NOT done. #14175's changeset text is no longer a
.changeset/file — it was consumed at release and now lives inpackages/spec/CHANGELOG.md, which AGENTS.md forbids editing in a code PR (a factual error in a released entry is amended in a dedicated docs-only PR). The erratum this PR can deliver is its own changeset, which is the live channel to an upgrading consumer. Flagged for the seat rather than taken.维护者速读(草稿)
改了什么。 动作处理器里查数据的写法统一了。以前
ctx.engine.find只收筛选条件本身,而平台其它地方的find都收完整查询信封,于是最自然的写法反而是错的 —— 多包一层where编译能过、运行不报错、永远返回空列表。现在这个参数就是引擎自己的查询类型:find('task', { where: { status: 'open' } })。为什么这样改。 裁决选的是 D:与其为了拦住写错的人而把
where变成全平台保留字(等于向每个客户的数据模型征用一个词),不如把参数形状本身收回来。代价对称了 —— 不保留任何词,并且顺带让处理器第一次能用fields/orderBy/limit分页和投影。风险与代价(含回滚)。 这是破坏性变更:老写法从今天起编译不过。好消息是它一定编译不过 —— 对象字面量和变量两条路都实测会报错,所以升级者跑一次
tsc就能拿到完整清单,不存在漏改后静默跑错的情况。仓内只有一个调用点,已改。真正要提醒升级者的是:之前写对了信封的人,他们的代码一直在静默返回空列表,所以不能只验证"还能跑",要验证"真的查出行来"。回滚 = revert 本 PR,无数据迁移、无存量元数据受影响。席位意见。
你要做的。 这个 diff 碰到了
skills/**(两个生成的引用索引,各 +3 行,不占技能包预算),属于 Tier H 治理面 —— 需要你点头才能落地,我已保持 draft。除此之外无需操作。Generated by Claude Code